Logging And Debugging Unhandled Promise Rejections In The Browser
Yesterday, I took a look at logging and debugging unhandled Promise rejections in Node.js using the process event, "unhandledRejection". As it turns out, this concept is also supported natively by the Chrome browser, albeit under a different event name, "unhandledrejection" (lowercase R). But, the popular Promise libraries, Bluebird and Q will also hook into this event if they see unhandled rejections in their Promise chains. This got me thinking - can we basically use Bluebird and Q to shim this functionality across browsers? I wanted to do a little experimentation.
As a recap from yesterday, having unhandled Rejections in a Promise chain - while not technically wrong - is more likely to be a bug than an intended strategy. If a Promise chain contains an error that is swallowed, it can lead to strange behaviors and hard to debug issues in your application. As such, the community has decided that having unhandled Rejections is a Bad Thing (tm) and is putting mechanism in place to help find and remove these problems. In fact, future versions of Node.js will view unhandled Rejections as a fatal error and will halt the execution of the node process.
Coming back to the Browser environment, I wanted to get a baseline of behavior. So, I created a simple demo in which I bind to the "unhandledrejection" event type on the window and then create a native Promise chain that is fulfilled with an unhandled rejection:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Logging And Debugging Unhandled Promise Rejections In The Browser
</title>
</head>
<body>
<h1>
Logging And Debugging Unhandled Promise Rejections In The Browser
</h1>
<h2>
Native / Shim Promise Implementation Only
</h2>
<p>
<em>Look at console — things being logged, yo!</em>
</p>
<script type="text/javascript" src="../../vendor/core-js/2.4.1/shim.min.js"></script>
<script type="text/javascript">
// Listen for unhandled Promise rejections.
// --
// NOTE: At the time of this writing, only Chrome has native support for this
// global event. Other browser may or may not log the root Error to the console,
// but the behavior differs depending on the Promise implementation (ie, native
// vs. shim).
window.addEventListener(
"unhandledrejection",
function handleRejection( event ) {
// Prevent the default behavior, which is logging the unhandled rejection
// error to the console.
// --
// NOTE: This is only meaningful in Chrome that supports this event.
event.preventDefault();
console.group( "UNHANDLED PROMISE REJECTION" );
console.log( event.reason );
console.log( event.promise );
console.groupEnd();
}
);
// Now that we have our global event-handler in place, let's create a Promise
// chain that results in a Rejection for which we provide no .catch() handler.
var promise = Promise
.resolve( "Come at me, bro!" )
.then(
function() {
throw( new Error( "Something went wrong!" ) );
}
)
;
</script>
</body>
</html>
If I run this code in Chrome and Firefox, I get the following console output:
At first glance, it might look like these two browsers are doing the same thing, but they aren't. Only Chrome is dispatching the "unhandledrejection" error - Firefox is logging the error to the console, but is not using my event handler. What's more is that I couldn't seem to hook into this error in Firefox using the window.onerror event handler. Maybe I was messing something up? Maybe its the core-js implementation shim doing something funky?
NOTE: The same is true of Safari. I tried to test on Internet Explorer, but my subscription to BrowserStack seems to be expired at the moment.
With the baseline of behavior recorded, I created a second demo that takes the rejected Promise and wraps it in a Bluebird Promise implementation:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Logging And Debugging Unhandled Promise Rejections In The Browser
</title>
</head>
<body>
<h1>
Logging And Debugging Unhandled Promise Rejections In The Browser
</h1>
<h2>
Bluebird Promise Implementation
</h2>
<p>
<em>Look at console — things being logged, yo!</em>
</p>
<!--
CAUTION: We have to include the CORE shim BEFOE we include BLUEBIRD. If we
include Bluebird first, the Core shim will overwrite the Promise reference.
-->
<script type="text/javascript" src="../../vendor/core-js/2.4.1/shim.min.js"></script>
<script type="text/javascript" src="../../vendor/bluebird/3.5.0/bluebird.min.js"></script>
<script type="text/javascript">
// Don't have Bluebird overwrite the global Promise reference. If we want to use
// the Bluebird promises, we want to make that an explicit choice in the code.
var Bluebird = Promise.noConflict();
// Listen for unhandled Promise rejections.
// --
// NOTE: At the time of this writing, only Chrome has native support for this
// global event. Other browser may or may not log the root Error to the console,
// but the behavior differs depending on the Promise implementation (ie, native
// vs. shim).
window.addEventListener(
"unhandledrejection",
function handleRejection( event ) {
// Prevent the default behavior, which is logging the unhandled rejection
// error to the console.
// --
// NOTE: This is only meaningful in Chrome that supports this event.
event.preventDefault();
// The native event-handler in Chrome puts the Reason and Promise in the
// root event, but Bluebird attaches them to the event detail.
var reason = ( event.reason || event.detail.reason );
var promise = ( event.promise || event.detail.promise );
console.group( "UNHANDLED PROMISE REJECTION" );
console.log( reason );
console.log( promise );
console.groupEnd();
}
);
// Now that we have our global event-handler in place, let's create a Promise
// chain that results in a Rejection for which we provide no .catch() handler.
var promise = Promise
.resolve( "Hello" )
.then(
function() {
throw( new Error( "Something went wrong!" ) );
}
)
;
// Just including the Bluebird library doesn't do anything (since we called
// .noConflict() in order to ensure Bluebird was not the global Promise
// implementation); we therefore have to wrap the native Promise object in a
// Bluebird promise in order to ensure that the down-chain functionality is
// provided by Bluebird. This will leave us with a Bluebird promise that is
// rejected with no .catch() handler ... which will cause Bluebird to trigger
// the global "unhandledrejection" event.
// --
// NOTE: If we didn't call Promise.noConflict(), we wouldn't need this step.
Bluebird.resolve( promise );
</script>
</body>
</html>
When you take one Promise implementation and wrap it in another Promise implementation, the rest of the Promise chain takes on the characteristics of the external Promise implementation. This means that in this demo, our native unhandled Promise Rejection will become a Bluebird unhandled Promise Rejection. And, Bluebird will emit an "unhandledrejection" event even in browsers that don't normally support it. As such, when we run this demo in Chrome and Firefox, we get the following console output:
As you can see, in this version, both browsers observe the "unhandledrejection" event and can log the given Error and Promise to the console as a group. We have successfully used Bluebird to shim support for the unhandled Promise Rejection event into Firefox. Of course, it only works if and when you wrap your native Promises in Bluebird Promises; but, it's a start.
CAUTION: If you include the Bluebird library, it will actually overwrite the native Promise implementation. I am very much on the fence about this. I kind of think that code should assume a native implementation and should explicitly wrap Promises in order to get a desired behavior. That's why I am calling .noConflict() in my demo.
The Q Promise library also hooks into the "unhandledRejection" event (uppercase R), but only in a Node.js context, where it calls process.emit("unhandledRejection"). For funzies, I wanted to see if I could mock out the process object in the browser and pipe the process.emit() call into the window.dispatchEvent() call.
CAUTION: This is totally experimental.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Logging And Debugging Unhandled Promise Rejections In The Browser
</title>
</head>
<body>
<h1>
Logging And Debugging Unhandled Promise Rejections In The Browser
</h1>
<h2>
Q Promise Implementation (Experimental Shim)
</h2>
<p>
<em>Look at console — things being logged, yo!</em>
</p>
<script type="text/javascript" src="../../vendor/core-js/2.4.1/shim.min.js"></script>
<script type="text/javascript" src="../../vendor/q/v1/q.js"></script>
<script type="text/javascript">
// Like the Bluebird library, the Q library will hook into Node.js'
// "unhandledRejection" on the global process. But, unlike Bluebird, Q does not
// hook into the browser-based event workflow. As such, I wanted to EXPERIMENT
// with shiming the Node.js process.emit() into the Browser.
(function( originalProcess ) {
var supportsNative = true;
// In Chrome, when using Q, the unhandled Promise rejection will actually
// trigger the native "unhandledrejection" event even when we wrap the
// Promise in Q.when(). As such, we want to un-mock the "process" if we
// detect that the window event handler is invoked before our mocked-out
// process object.
window.addEventListener(
"unhandledrejection",
function handleRejection( event ) {
if ( supportsNative ) {
// Restore the previous process (likely undefined).
window.process = originalProcess;
}
// We only need to test this once.
window.removeEventListener( "unhandledrejection", handleRejection );
}
);
// Stub-out the Node.js process.emit() that Q will look for.
window.process = {
emit: function( eventType ) {
supportsNative = false;
if ( eventType === "unhandledRejection" ) {
var event = new Event( "unhandledrejection" );
// I'm using the "detail" here to mimic what Bluebird does.
event.detail = {
reason: arguments[ 1 ],
promise: arguments[ 2 ]
};
return( window.dispatchEvent( event ) );
}
}
};
})( window.process );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Listen for unhandled Promise rejections.
// --
// NOTE: At the time of this writing, only Chrome has native support for this
// global event. Other browser may or may not log the root Error to the console,
// but the behavior differs depending on the Promise implementation (ie, native
// vs. shim).
window.addEventListener(
"unhandledrejection",
function handleRejection( event ) {
// Prevent the default behavior, which is logging the unhandled rejection
// error to the console.
// --
// NOTE: This is only meaningful in Chrome that supports this event.
event.preventDefault();
// The native event-handler in Chrome puts the Reason and Promise in the
// root event, but Bluebird attaches them to the event detail.
var reason = ( event.reason || event.detail.reason );
var promise = ( event.promise || event.detail.promise );
console.group( "UNHANDLED PROMISE REJECTION" );
console.log( reason );
console.log( promise );
console.groupEnd();
}
);
// Now that we have our global event-handler in place, let's create a Promise
// chain that results in a Rejection for which we provide no .catch() handler.
var promise = Promise
.resolve( "Hello" )
.then(
function() {
throw( new Error( "Something went wrong!" ) );
}
)
;
// In order to get Q to monitor the unhandled Rejections, we have to wrap any
// native Promise chain in a Q Promise. Doing so will ensure that the down-chain
// functionality is provided by Q. This will leave us with a Q promise that is
// rejected with no .catch() handler ... which will cause Q to emit the PROCESS
// EVENT "unhandledRejection", which we are shimming into the Browser event,
// "unhandledrejection".
Q.when( promise );
</script>
</body>
</html>
Here, I'm creating a mock process object off of the window and providing it with a single method, emit(). Then, when Q goes to invoke process.emit(), I am turning around, creating a custom "unhandledrejection" event with the given arguments, and dispatching it on the window object.
Now, you can see that the code actually does a bit more than that. For some reason, in Chrome, which natively supports the "unhandledrejection" event, the error was being logged twice: once natively and once by Q. It seems that, unlike Bluebird, Q monitors unhandled rejections in a way that doesn't attach a catch handler (at least that's my guess). As such, I have to jump through a few hoops to un-mock the process object if it looks like the browser natively supports the "unhandledrejection" event.
In any case, when we run this code in Chrome and Firefox, we get the following console output:
As you can see, in this version, both browsers observe the "unhandledrejection" event and can log the given Error and Promise to the console as a group. We have successfully used Q to shim support for the unhandled Promise Rejection event into Firefox.
This is a fun experiment, but I suspect it won't be needed in the future. Meaning, now that the Node.js community and Chrome have agreed that having unhandled Rejections is a bad thing worth monitoring, I'd be surprised if the other browsers didn't follow-suit. Of course, in the meantime, if you use Bluebird or Q as your browser-based Promise implementation, you can get this functionality today as long as you take care to wrap all of your "untrusted" Promises.
Want to use code from this post? Check out the license.
Reader Comments